Skip to content

Feat: add generation-safe depth-two pipeline slots - #1540

Merged
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
Crane-Liu:codex/worker-async-b1-depth2-leases
Jul 29, 2026
Merged

Feat: add generation-safe depth-two pipeline slots#1540
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
Crane-Liu:codex/worker-async-b1-depth2-leases

Conversation

@Crane-Liu

@Crane-Liu Crane-Liu commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Permit A2/A3 host runtimes to declare pipeline_depth = 2 and give every resource class the copy count its declaration implies, without admitting a second run into device execution.

  • HOST_PER_RUN and EXEC_HANDLE get one copy per slot, DEVICE_SCRATCH keeps one; pipeline_resource_copy_count / pipeline_resource_slot derive both from the contract so no caller hardcodes a replication rule.
  • GM heap, GM shared memory, and the runtime image commit into one arena bank together, so has_serviceable_arena_topology rejects a contract that classifies them inconsistently — or repeats one, where only the first entry is read — when the runtime loads, not at the first launch that would need a second bank. Repeated stream kinds select no bank and stay legal.
  • Each slot owns a host Runtime staging buffer. That buffer is not the RUNTIME_IMAGE resource: the contract classifies the device-resident image, while this holds the host-side object a run constructs in place, with its tensor leases, launch arguments and validate/finalize state. That is per-run whatever the device image sharing is, so TMR — whose image is DEVICE_SCRATCH — still gets one buffer per slot.
  • The A2/A3 runner owns its slot and arena-bank selection as member state, so two contexts on one thread cannot see each other's choice. Simulation implements the same depth — one arena bank and one retained temporary buffer per slot, single-entry prebuilt-arena cache owned by bank 0 — so the contract means the same thing on both platforms.
  • A destroy the run cannot complete is that run's failure: the retire path returns the driver's error and keeps the handle, leaving the slot unusable for the next run and leaving teardown something to retry. That state machine lives in RunStreamSlots with injected create/destroy, so its failure paths are covered without a device.
  • AICore streams are created fresh for every run and retired on every exit path. The instruction cache belongs to the cores, every slot publishes to the same GM code address, and the platform offers no invalidation for code replaced there. Creating a stream is the only operation known to leave a core free of the previous image's instructions — selecting an existing one is not — so no record of which image a stream last ran can make reuse safe, and none is kept. The AICPU stream carries no such state and stays with its slot.

The ordinary synchronous path stays on slot 0, and the chip child's mailbox loop passes no lease, so every production run is unleased. No prepared FIFO, second mailbox frame, or backend publication overlap is enabled here.

Known boundaries

These are deliberate and belong to whole-run admission (#1541) or later, not to B1:

  • The generation filter is a replay filter, not an ownership check. ChipWorker is downstream of PipelineSlotPool and never sees an acquire or release, so it keeps a per-slot high-water mark. That rejects a superseded lease, but one released while its successor has not yet reached the consumer still passes. Closing the window needs dispatch gated on PipelineSlotPool::owns. Stated in pipeline_slot_pool.h and docs/task-flow.md.
  • rtStreamCreate is still on the critical path. It runs inside DeviceRunner::run() ahead of launch_run, so it overlaps nothing. Moving it into a prepare stage — stream owned by {slot, generation}, destroyed on slot reuse / prepare failure / cancel, ownership re-verified before launch — is async-preparation work.
  • No lease crosses the mailbox. run_from_blob accepts slot/generation, but _run_chip_main_loop passes neither.

Validation

All on af454c75, rebased onto upstream/main@40b6329a.

Result
pre-commit all hooks passed
C++ ctest (no-hardware) 64/64
pytest tests/ut 843 passed, 2 skipped
a2a3sim examples tests/st exit 0, no failures
a2a3 onboard — resource phase 52/52
a2a3 onboard — L2 host_build_graph PASS
a2a3 host_build_graph/run_stream_reuse 6/6
a2a3 tensormap_and_ringbuffer/pipeline_slots 2/2
a2a3sim, both slot suites 4 passed (the two depth-two resource cases run on sim)

The CI-shaped onboard sweep reached 84% of the L2 tensormap_and_ringbuffer
phase with zero failures before the task-submit daemon restarted; the remaining
16% is being re-run and will be posted separately.

Mutation testing

Depth-two tests are checked against deliberate defects rather than assumed to have teeth:

Mutant Result
slot 1's Runtime buffer and arena bank collapse onto slot 0 caughta served bank is uncommitted: 0x12c081600000, 0x0
unleased runs mint lease generations caughtrun pipeline lease generation is stale
AICore stream keyed on the slot's own last image caughtslot 1 reused its stream after another slot published a different image
a failed rtStreamDestroy drops the handle and reports success caught — three separate assertions fire: the error is not returned, the handle is gone, and the next acquire is admitted
arena-bank selection moved back into a thread-local not caught — see below

The last has no end-to-end test. Poisoning the selection needs a context that picks bank ≠ 0 (only an hbg-classified contract does), while observing it needs arenas laid out before any selection (only TMR's prewarm_config_impl does; hbg links the weak no-op). That combination needs an hbg worker leasing slot 1 and a TMR worker prewarming in one process and thread, which no scene-test class can construct. The fix is structural — two objects cannot share a member — but no coverage is claimed for it.

Cost of fresh streams per run

This gives back part of #1464's persistent-AICore-stream gain. On a 128×128 vector callable, 300 runs after 20 warm-ups:

build median host wall / run streams created
reuse 10.57 / 11.35 ms 1
fresh per run 11.37 / 11.94 / 12.14 ms 320

Read as an upper bound of roughly +1 ms/run, not a measurement: two reuse builds differ by 0.78 ms and the fresh samples span 0.77 ms, so this shared box's spread is the size of the effect. The delta is one rtStreamCreate + rtStreamDestroy per run — bounded, and proportionally smaller on larger workloads.

Stack

PR-B1 of the worker async pipeline. #1541 (bounded whole-run FIFO admission) stacks on this.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change introduces a two-phase run lifecycle with explicit acceptance tracking, generation-safe pipeline-slot leases, per-slot runtime resources, AICore image hashing, and image-aware stream reuse across C++, Python bindings, documentation, and tests.

Changes

Acceptance lifecycle

Layer / File(s) Summary
Run acceptance propagation
src/common/hierarchical/*, src/common/platform/onboard/host/*, src/common/worker/*, python/simpler/*, python/bindings/*, docs/*, tests/ut/*
Runs track pending acceptance obligations; mailbox endpoints publish TASK_ACCEPTED; orchestrator and Python layers expose acceptance waits and queries separately from completion.
Pipeline contract and lease execution
src/common/worker/*, src/common/platform/*, python/*, docs/task-flow.md, tests/ut/cpp/hierarchical/*
Pipeline depth supports multiple slots with generation-validated leases, per-slot buffers, arena selection, accepted-state binding, and Python execution helpers.
Image-aware callable and stream reuse
src/common/task_interface/*, src/common/utils/*, src/a2a3/*, src/a5/*, tests/st/a2a3/*, tests/ut/cpp/types/*
Callable registration carries an AICore image hash, and A2A3 stream sets are reused or recreated according to pipeline slot and image identity.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PythonWorker
  participant ChipWorker
  participant DeviceRunner
  participant Orchestrator
  PythonWorker->>ChipWorker: run_with_lease(slot_id, generation)
  ChipWorker->>DeviceRunner: select pipeline slot and arena bank
  DeviceRunner->>DeviceRunner: enqueue kernels and publish TASK_ACCEPTED
  DeviceRunner->>Orchestrator: mark task accepted
  Orchestrator-->>PythonWorker: acceptance wait completes
Loading

Possibly related PRs

Poem

A rabbit hops where task streams gleam,
Slots take turns in a threaded dream.
Accepted hops before done’s refrain,
Old leases fade, new ones gain.
Hashes guide the kernels bright—
Binky, the pipeline runs just right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: generation-safe depth-two pipeline slot support.
Description check ✅ Passed The description is directly related to the pipeline-slot, acceptance, and AICore image-hash changes in the PR.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/common/hierarchical/worker_manager.cpp (1)

279-304: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

accept_once() can crash the worker thread if on_accept_ throws.

accept_once() at line 299 runs unconditionally after the dispatch_process try/catch, but is itself not exception-protected. If on_accept_(d) (here, Orchestrator::mark_task_accepted) throws on this call path (i.e. it wasn't already invoked during dispatch), the exception escapes WorkerThread::loop, the top-level routine of a detached std::thread, which calls std::terminate on an escaping exception — crashing the whole process instead of just failing the one task, unlike every other failure mode here which is converted into a failed WorkerCompletion.

🛡️ Proposed fix
         WorkerCompletion completion;
         bool accepted = false;
-        auto accept_once = [&]() {
-            if (accepted) return;
-            accepted = true;
-            if (on_accept_) on_accept_(d);
-        };
+        auto accept_once = [&]() noexcept {
+            if (accepted) return;
+            accepted = true;
+            if (!on_accept_) return;
+            try {
+                on_accept_(d);
+            } catch (...) {
+                // Acceptance notification failures must not crash the worker
+                // thread; dispatch completion is unaffected.
+            }
+        };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/common/hierarchical/worker_manager.cpp` around lines 279 - 304, Protect
the unconditional accept_once() call in WorkerThread::loop so exceptions from
on_accept_ are converted into the current task’s failed WorkerCompletion instead
of escaping the worker thread. Reuse the existing ENDPOINT_FAILURE fields and
error-message conventions, ensure completion is still finalized through
on_complete_, and preserve the existing dispatch_process handling and one-time
acceptance behavior.
src/a2a3/platform/onboard/host/device_runner.cpp (1)

490-530: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Stale AICore stream handle retained if rtStreamDestroy fails.

When the AICore image hash changes, the retired stream is destroyed at Line 509; on failure the function returns rc immediately without clearing streams.aicore / streams.has_aicore_image. A later call with the same (new) hash will then hit the early-return at Line 504 and reuse a handle that a prior rtStreamDestroy call already attempted to tear down — this contrasts with destroy_run_stream_sets() (Line 550-558), which unconditionally nulls the handle even when destroy fails.

🐛 Proposed fix: always clear retired-stream state before returning
     if (streams.aicore != nullptr) {
         int rc = rtStreamDestroy(streams.aicore);
+        streams.aicore = nullptr;
+        streams.has_aicore_image = false;
         if (rc != 0) {
             LOG_ERROR("rtStreamDestroy (retired AICore slot %u) failed: %d", slot, rc);
-            return rc;
+            return rc;
         }
-        streams.aicore = nullptr;
-        streams.has_aicore_image = false;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/a2a3/platform/onboard/host/device_runner.cpp` around lines 490 - 530,
Update DeviceRunner::ensure_run_stream_set so the retired AICore stream handle
and has_aicore_image state are cleared immediately after rtStreamDestroy is
attempted, including when destruction fails, before returning the error code.
Preserve the existing error logging and stream recreation flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/worker-manager.md`:
- Around line 175-181: Update the mailbox example’s preceding function signature
from run(...) to run_with_accept(..., on_accept), then modify the TASK_ACCEPTED
handling in the polling loop so on_accept() is invoked only once while
preserving TASK_DONE as the termination condition.

In `@python/simpler/worker.py`:
- Around line 2124-2147: Update _wait_for_acceptance so the state updates and
waiter notification are protected by a try/finally after
_wait_run_handle_accepted returns. Ensure _accept_wait_in_progress is always
reset and _cv.notify_all() always runs, while preserving _launch_accepted = True
only on successful acceptance and re-raising failures.

In `@src/common/hierarchical/orchestrator.cpp`:
- Line 528: Move increment_run_accepts(run->id, s.group_size()) to immediately
after the poisoned_by_failed_producer branch returns, before the normal
READY/PENDING enqueue path, so producer-poisoned slots are excluded from pending
accepts while dispatched members retain the correct count.

In `@src/common/platform/onboard/host/c_api_shared.cpp`:
- Around line 653-679: Bind g_runner_key to the runner represented by ctx for
the duration of set_task_accepted_state_ctx, select_pipeline_slot_ctx, and
select_arena_bank_ctx, following the guard and cleanup convention used by
simpler_run. Ensure the binding is established before each direct
DeviceRunnerBase call and cleared on every return path, including exceptions.

In `@src/common/worker/chip_worker.cpp`:
- Around line 121-125: Add non-null stub exports for select_pipeline_slot_ctx
and select_arena_bank_ctx in the a5 sim and a5 onboard host runtime C API
implementations. Follow the signatures and no-op behavior of the existing
c_api_shared.cpp implementations so ChipWorker::init() can load both required
symbols successfully.

---

Outside diff comments:
In `@src/a2a3/platform/onboard/host/device_runner.cpp`:
- Around line 490-530: Update DeviceRunner::ensure_run_stream_set so the retired
AICore stream handle and has_aicore_image state are cleared immediately after
rtStreamDestroy is attempted, including when destruction fails, before returning
the error code. Preserve the existing error logging and stream recreation flow.

In `@src/common/hierarchical/worker_manager.cpp`:
- Around line 279-304: Protect the unconditional accept_once() call in
WorkerThread::loop so exceptions from on_accept_ are converted into the current
task’s failed WorkerCompletion instead of escaping the worker thread. Reuse the
existing ENDPOINT_FAILURE fields and error-message conventions, ensure
completion is still finalized through on_complete_, and preserve the existing
dispatch_process handling and one-time acceptance behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: de4abed5-9b47-48bc-ae3e-e66e53db0abf

📥 Commits

Reviewing files that changed from the base of the PR and between f44c715 and 880cf3f.

📒 Files selected for processing (39)
  • docs/orchestrator.md
  • docs/task-flow.md
  • docs/worker-manager.md
  • python/bindings/task_interface.cpp
  • python/bindings/worker_bind.h
  • python/simpler/orchestrator.py
  • python/simpler/task_interface.py
  • python/simpler/worker.py
  • src/a2a3/platform/onboard/host/device_runner.cpp
  • src/a2a3/platform/onboard/host/device_runner.h
  • src/a2a3/runtime/host_build_graph/host/runtime_maker.cpp
  • src/a2a3/runtime/tensormap_and_ringbuffer/host/runtime_maker.cpp
  • src/a5/runtime/host_build_graph/host/runtime_maker.cpp
  • src/a5/runtime/tensormap_and_ringbuffer/host/runtime_maker.cpp
  • src/common/hierarchical/orchestrator.cpp
  • src/common/hierarchical/orchestrator.h
  • src/common/hierarchical/types.h
  • src/common/hierarchical/worker.cpp
  • src/common/hierarchical/worker_manager.cpp
  • src/common/hierarchical/worker_manager.h
  • src/common/platform/onboard/host/c_api_shared.cpp
  • src/common/platform/onboard/host/device_runner_base.cpp
  • src/common/platform/onboard/host/device_runner_base.h
  • src/common/platform/sim/host/c_api_shared.cpp
  • src/common/task_interface/chip_callable_layout.h
  • src/common/task_interface/prepare_callable_common.h
  • src/common/utils/fnv1a_64.h
  • src/common/worker/chip_worker.cpp
  • src/common/worker/chip_worker.h
  • src/common/worker/pipeline_contract.h
  • src/common/worker/pipeline_slot_pool.h
  • src/common/worker/pto_runtime_c_api.h
  • tests/st/a2a3/host_build_graph/run_stream_reuse/kernels/aiv/kernel_sub.cpp
  • tests/st/a2a3/host_build_graph/run_stream_reuse/test_run_stream_reuse.py
  • tests/ut/cpp/hierarchical/test_orchestrator.cpp
  • tests/ut/cpp/hierarchical/test_pipeline_contract.cpp
  • tests/ut/cpp/hierarchical/test_scheduler.cpp
  • tests/ut/cpp/types/test_chip_callable_upload_immutable.cpp
  • tests/ut/py/test_worker/test_host_worker.py

Comment thread docs/worker-manager.md Outdated
Comment thread python/simpler/worker.py
Comment thread src/common/hierarchical/orchestrator.cpp
Comment thread src/common/platform/onboard/host/c_api_shared.cpp
Comment thread src/common/worker/chip_worker.cpp
@ChaoWao
ChaoWao force-pushed the codex/worker-async-b1-depth2-leases branch from 880cf3f to ee420e7 Compare July 28, 2026 09:30
@ChaoWao

ChaoWao commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto upstream/main@40b6329a and pushed fixes for the review findings. Head is now ee420e73 — one commit, 17 files, +773 −104.

Rebase: one commit was dropped, deliberately

git rebase dropped d3c60ba3 automatically (patch contents already upstream — it is #1539's content, which #1467's squash carried in). I then skipped 8023d914, and that one deserves an explicit note so it does not look like lost work.

8023d914 is the pre-review version of #1467. It still carries the transient state:

enum class MailboxState : int32_t {
    ...
    TASK_ACCEPTED = 8,
};

which loses the ACK whenever the child reaches TASK_DONE between two parent polls — exactly the short-task case the fence exists to pipeline. main (b072b5b9) has the fixed version: a sticky word at MAILBOX_OFF_ACCEPTED = MAILBOX_OFF_ERROR_MSG - 8, cleared only when the parent publishes the next TASK_READY. Keeping 8023d914 would have reverted that. Nothing unique to it is lost.

Findings addressed

1. Two independent generation authorities (was the blocker). PipelineSlotPool had no caller outside its own unit test; the mechanism that actually ran was ChipWorker::latest_pipeline_generations_, a monotone watermark that next_default_lease() advanced on every ordinary run. Since a fresh pool starts each slot at generation 1, any ChipWorker that had served ordinary runs rejected the pool's first lease. Verified on a2a3 before the fix:

>>> PROBE: slot 0 generation 1 REJECTED after 3 ordinary runs:
    run pipeline lease generation is stale

That is squarely on #1541's path — it acquires from pipeline_slots_.try_acquire(admission_depth_) in Orchestrator::begin_run().

Fixed by removing the second authority. next_default_lease() is gone; an unleased run takes slot 0 and advances nothing. ChipWorker now holds a PipelineSlotGenerationFilter whose contract is stated in the header: the pool mints and owns, the consumer only rejects replays of superseded generations. Regression: test_unleased_runs_do_not_consume_lease_generations.

2. The depth-two ST had no teeth. It asserted stream-set counts and generation rejection — both #1467/#1539 mechanisms already on main — and nothing about the resource split. Collapsing slot 1's Runtime buffer and arena bank onto slot 0 left all four tests green. Added ChipWorker::runtime_buffer_addrs and get_arena_bank_gm_heap_base_ctx so test_depth_two_slots_own_separate_resources can tell two copies from two names for one copy; the same mutant now fails it.

3. Slot/bank selection was thread-local. g_pipeline_slot / g_arena_bank were file-scope thread_local, so every DeviceRunnerBase on a thread shared one selection, finalize_common() did not reset it, and a per-runner array (retained_temp_addrs_) was indexed by a per-thread variable. Now pipeline_slot_ / arena_bank_ members, reset in finalize_common(). See the PR description for why this one has no end-to-end test.

4. runtime_bufs_ bypassed the contract this PR defines. It replicated by raw pipeline_depth while every other resource went through pipeline_resource_copy_count. tmr classifies RUNTIME_IMAGE as DEVICE_SCRATCH, so it was getting two host buffers for a resource its own contract says is shared — and at slot 1 would have read the second buffer while select_arena_bank(0) pointed the device at bank 0. Now derived from the classification.

5. _bank1_-suffixed members hardcoded depth 2 into identifiers. Six members plus seven bank == 0 ? x_ : x_bank1_ ternaries became std::array<std::unique_ptr<ArenaBank>, PTO_PIPELINE_MAX_DEPTH>. Raising the depth is now a constant change. retained_temp_addrs_ / retained_temp_sizes_ became std::array too (codestyle §8).

6. Bank 1 silently lost the prebuilt-arena cache. Both arena_bank_ != 0 early-returns now state that the cache holds one entry whose bases point into bank 0.

Not changed

select_pipeline_slot_ctx / select_arena_bank_ctx stay required (load_symbol, not load_optional_symbol). A stale build/lib/*.so then fails init() with a clear missing-symbol error instead of silently degrading, which matches the _task_interface build-stamp guard. The new get_arena_bank_gm_heap_base_ctx is optional, since it is pure diagnostics.

Full validation table is in the PR description.

@ChaoWao
ChaoWao force-pushed the codex/worker-async-b1-depth2-leases branch 2 times, most recently from ea9af7f to 6a6f714 Compare July 29, 2026 01:31
@ChaoWao

ChaoWao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Taking the correction: AICore streams are now created fresh for every Worker.run and retired on every exit path. Head is 6a6f7149.

You're right that the epoch was built on an unproven premise. It assumed that selecting a stream whose recorded image matches is as good as creating one; the only operation we have evidence for is creation. Tracking aicore_image_hash could then never make reuse safe, so nothing records it any more — the field stays for registration bookkeeping and carries no correctness duty.

  • AICore stream: rtStreamCreate per run, rtStreamDestroy after reap through an RAII guard, so a failed launch or reap cannot leave one behind. ensure_run_stream_set refuses a slot that still holds one rather than reusing it.
  • AICPU stream: unchanged, persistent per slot — it carries no instruction-cache state.
  • run_stream_set_create_count now advances once per run; its docstring and docs/task-flow.md say so.

On the trace in your finding: it describes ee420e73. The intermediate ea9af7f4 did make its third step create a fresh stream — restoring the per-slot key made the new test fail with assert 7 == (7 + 1) — but that is moot under the per-run rule, and the epoch is gone.

Tests

The old assertions pinned the opposite invariant, so they inverted rather than extended:

before now
test_one_stream_set_serves_repeated_runs test_every_run_creates_its_own_aicore_stream — N runs, N creations
test_aicore_stream_tracks_code_image test_alternating_code_images_never_execute_stale_instructions — A→B→A→B, each result compared against that image's golden, so a core running the previous image's instructions surfaces as wrong data rather than a count mismatch
cross-slot epoch test deleted, subsumed

test_depth_two_slot_is_generation_safe now also asserts a rejected lease creates no stream, since it must not reach launch.

Cost

This partly gives back #1464's persistent-AICore-stream gain, so here is what it measures at, on a 128×128 vector callable, 300 runs after 20 warm-ups:

build median host wall / run streams created
reuse (hacked build) 10.57 ms 1
reuse (epoch build) 11.35 ms 1
fresh per run 11.37 / 11.94 / 12.14 ms 320

Read that as an upper bound of roughly +1 ms/run, not a measurement: the two reuse builds differ by 0.78 ms and the fresh samples span 0.77 ms, so this shared box's run-to-run spread is the same size as the effect. The delta is one rtStreamCreate + rtStreamDestroy per run, so it is bounded and shrinks proportionally on larger workloads. A clean number needs an interleaved A/B, which needs both variants loadable in one process.

I think the trade is right — a guarantee that holds by construction beats a sub-noise host-time win — but flagging it explicitly because #1464 is merged and this reverses part of it. Say the word if you would rather keep same-image reuse and accept the narrower guarantee.

Also in this push

The three other findings from the previous round are unchanged and still in: host Runtime back to one buffer per slot (it is not the RUNTIME_IMAGE resource), has_serviceable_arena_topology rejecting unserviceable contracts at load, and sim refusing any slot but 0 instead of silently sharing its single arena set.

Validation on 6a6f7149: ctest 63/63, pytest tests/ut 843 passed / 2 skipped, run_stream_reuse 6/6 on a2a3. Sim and the full onboard sweep are running; I will post their results.

@ChaoWao

ChaoWao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Head is 1b2ab0c2. All four findings addressed; PR description rewritten (it was still describing ee420e73).

[P1] sim declaring K=2 while rejecting slot 1 — took the first of your two options: sim now really implements K=2. SimDeviceRunnerBase got the same shape the onboard runner already has — one ArenaBank per slot in a std::array, per-slot retained_temp_addrs_, pipeline_slot_/arena_bank_ members — wired through sim's c_api_shared and released across all banks in both sim finalize() paths. My previous "sim refuses any slot but 0" was the wrong half of the either/or: it made the mismatch loud instead of removing it. The new TMR test leases slot 1 on sim and passes, which is the direct evidence.

[P2] generation filter is not an ownership check — correct, and your timing is exactly the hole: g1 released → g2 leased → g2 has not reached ChipWorker → delayed g1 still passes, because nothing raised the mark. Now stated where it can be found rather than only here — pipeline_slot_pool.h says it is "strictly weaker than an ownership check, and deliberately so", names the window, and points at gating dispatch on PipelineSlotPool::owns in the admission layer. docs/task-flow.md carries the same, replacing the old claim that "an older generation cannot reach launch". Also listed under Known boundaries in the PR description so B2 inherits it explicitly.

[P2] TMR slot-1 path uncovered — added tests/st/a2a3/tensormap_and_ringbuffer/pipeline_slots/. It interleaves slot0→slot1→slot0→slot1 leased runs, checking the result each time, that the two host Runtime buffers differ, and — TMR-specific — that slot 1 does not commit a second device-scratch bank, since TMR classifies its three pooled regions DEVICE_SCRATCH. 2/2 on a2a3, 2/2 on a2a3sim.

[P2] stale stream-reuse docs — swept: the contradictory docs/task-flow.md paragraph, the get_run_stream_set_create_count C ABI comment, the nanobind docstring, and the task_interface.py / worker.py properties. rg "reused only while|same-image runs reuse|is reused for every run" is now empty. PR description rewritten too — it had claimed TMR keeps one host Runtime buffer and that same-image runs reuse the stream, both opposite to the code.

On stream creation and prepare — agreed, no disagreement to record. rtStreamCreate is still inside DeviceRunner::run() ahead of launch_run, so it overlaps nothing. Moving it into a prepare stage, with the stream owned by {slot, generation} and destroyed on slot reuse / prepare failure / cancel plus ownership re-verified before launch, is async-preparation work. Listed under Known boundaries rather than implied to be done.

Two findings that were about main, not this diff — filed rather than resolved away: #1555 (_wait_for_acceptance assigns outside try/finally) and #1556 (producer-poisoned slots inflate pending_accepts). On #1556 I did adjust the severity: it does not strand the fence, since acceptance_ready() has an is_terminal disjunct — it delays the fence from acceptance to completion, which silently serialises the next submit().

On churn — 1092 lines noted. The split you suggest (contract/lease/filter vs platform slot resources + stream realization) is a clean seam; happy to do it before merge if you want it split.

Validation on 1b2ab0c2: ctest 63/63, pytest tests/ut 843 passed / 2 skipped, a2a3sim examples tests/st exit 0, a2a3 run_stream_reuse 6/6, a2a3 pipeline_slots 2/2. Full onboard sweep re-running against this head.

@ChaoWao
ChaoWao force-pushed the codex/worker-async-b1-depth2-leases branch from 1b2ab0c to 3cf4c8d Compare July 29, 2026 03:21
Permit A2/A3 host runtimes to declare `pipeline_depth = 2` and give every
resource class the copy count its declaration implies, without admitting a
second run into device execution. `HOST_PER_RUN` and `EXEC_HANDLE` gain one
copy per slot, `DEVICE_SCRATCH` keeps one, and `pipeline_resource_copy_count`
/ `pipeline_resource_slot` derive both from the contract so no caller
hardcodes a replication rule.

GM heap, GM shared memory, and the runtime image are committed together into
one arena bank, so a contract that classifies them inconsistently — or repeats
one of them, where only the first entry is ever read — describes a layout no
runner can build. `has_serviceable_arena_topology` rejects it when the runtime
loads rather than at the first launch that would need the second bank.
Repeated stream kinds select no bank and stay legal.

A run reaches its resources through a `PipelineSlotLease {slot_id,
generation}`. `PipelineSlotPool` is the only mint and the only authority on
ownership: it hands out one lease per free slot, bumps the generation on
reuse, and makes release idempotent for the current generation while
rejecting a stale one. Consumers downstream of the pool cannot re-derive
ownership, so `ChipWorker` carries a `PipelineSlotGenerationFilter` that only
refuses generations older than the newest that slot has presented. The filter
mints nothing: an unleased run selects slot 0 and advances no generation, so a
pool's first lease is still admitted after any number of ordinary runs.

Each slot owns a host `Runtime` staging buffer. That buffer is not the
`RUNTIME_IMAGE` resource — the contract classifies the device-resident image,
while this holds the host-side object a run constructs in place, with its
tensor leases, launch arguments, and validate/finalize state. That is per-run
whatever the device image sharing is, so a runtime whose image is
`DEVICE_SCRATCH` still needs its own buffer per slot.

The A2/A3 runner owns its slot and arena-bank selection as member state, so
two contexts on one thread cannot see each other's choice — a fresh worker's
prewarm lays out its arenas in the bank it will actually serve runs from. The
three pooled device regions become one bank per slot, and the single-entry
prebuilt-arena cache stays owned by bank 0.

AICore streams are outside that lease and outside the slot: each run creates
its own and retires it on every exit path. The instruction cache belongs to the
cores, every slot publishes its image to the same GM code address, and the
platform offers no cache invalidation for code replaced there. Creating a
stream is the only operation known to leave a core free of the previous image's
instructions — selecting an existing one is not — so no record of which image a
stream last ran can make reuse safe, and none is kept. The AICPU stream carries
no such state and stays with its slot for the worker's lifetime.

Simulation implements the same depth, so the contract means the same thing on
both platforms: its runner owns one arena bank and one retained temp buffer per
slot, and a leased slot-1 run there exercises the second copy rather than being
refused.

The generation filter is a replay filter, not an ownership check, and says so:
it rejects a lease whose successor has already presented itself, but one that
was released while its successor has not yet reached the consumer still passes.
Closing that window needs dispatch gated on `PipelineSlotPool::owns`, which
belongs to whoever admits runs.

The ordinary synchronous path stays on slot 0 and the chip child's mailbox
loop passes no lease, so every production run is unleased. No prepared FIFO,
second mailbox frame, or backend publication overlap is enabled here.

A run owns its AICore stream, so a destroy it cannot complete is that run's
failure: the retire path returns the driver's error and keeps the handle, which
both leaves the slot unusable for the next run — the stream may still hold the
previous image's instructions — and leaves teardown something to retry.
Reporting success there would have told the caller a slot is reusable that the
next acquire now refuses. That state machine lives in `RunStreamSlots`, whose
stream creation and destruction are injected, so the failure paths are covered
without a device.

`get_arena_bank_gm_heap_base_ctx`, `get_retained_temp_addr_ctx`, and
`ChipWorker::runtime_buffer_addrs` report which storage a bank and a slot
actually own. Sequential runs re-stage their arguments every round, so correct
output alone survives two slots sharing one buffer; the tests compare addresses
instead.
@ChaoWao
ChaoWao force-pushed the codex/worker-async-b1-depth2-leases branch from 3cf4c8d to af454c7 Compare July 29, 2026 03:54
@ChaoWao

ChaoWao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Head is af454c75. 三条全部落实。

1. rtStreamDestroy 失败回归测试

把 slot 状态机抽成 RunStreamSlotssrc/common/platform/include/host/run_stream_slots.h),stream 的 create/destroy 用 std::function 注入 —— 是依赖注入,不是新的行为门控,所以不触发 env-macro-gating 那条规则。DeviceRunnerrtStreamCreate / rtStreamDestroy 构造它,生产行为不变。

tests/ut/cpp/hierarchical/test_run_stream_slots.cpp(no-hardware,6 个用例)逐条覆盖你要求的三点:

fake.fail_next_destroys(1);
EXPECT_EQ(slots.retire_aicore(0), -13);   // 1. 错误返回
EXPECT_EQ(slots.aicore(0), stranded);     // 2. 句柄保留
EXPECT_NE(slots.acquire(0), 0);           // 3. 下一轮拒绝复用
EXPECT_EQ(slots.destroy_all(), 0);        //    teardown 重试成功

变异验证:把 retire_aicore 回退成「失败也清句柄、吞错误」,第 99 / 102 / 105 行分别报错,正好一一对应这三项,而不是笼统挂掉。另外还覆盖了:被卡住的 slot 不拖累另一个 slot、destroy_all 只保留销毁失败的那条、create 失败不留半拥有状态、越界 slot 被拒。

2. 旧文档清理

位置 原先的错误说法
docs/task-flow.md sim 只支持 slot 0
docs/task-flow.md runner-scoped retained temporary buffer
src/common/platform/include/common/host_api.h 平台只记一个 retained slot;"three per-Worker pooled regions"
src/a2a3/.../RUNTIME_LOGIC.mdsrc/a5/.../RUNTIME_LOGIC.md runner-scoped retained buffer
src/common/platform/sim/host/device_runner_base.h 已不存在的单个 runtime_arena_pool_
HBG 测试类 docstring 重复运行共享一个 stream set

rg "runner-scoped retained|single run stream set|reused only while|same-image runs reuse|Simulation serves slot 0" 现在为空。onboard runner header 的「同 image 复用」在上一轮已改。

3. PR 描述

Validation 表已改为 af454c75 的结果(ctest 64/64,含新目标),变异表加了这一轮的 rtStreamDestroy 项。

仍未结案:flat 形态下的段错误

CI 形态 sweep 跑到 L2 tensormap_and_ringbuffer 的 84%(零 FAIL、零段错误)后被 task-submit daemon 重启打断,不是超时也不是测试失败。剩下 16% 正在补跑,结果会单独贴。相关的 main 侧失败已由 #1561 解释(四个 notify demo 硬编码平台与设备 0/1),但那不解释分支侧的段错误,所以我没把它并进去。

@ChaoWao
ChaoWao merged commit e51c3ae into hw-native-sys:main Jul 29, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants